/* * ConnectBot: simple, powerful, open-source SSH client for Android * Copyright 2015 Kenny Root, Jeffrey Sharkey * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.connectbot.util; import org.connectbot.R; import org.connectbot.TerminalView; import org.connectbot.service.TerminalBridge; import android.annotation.TargetApi; import android.content.Context; import android.graphics.Color; import android.graphics.Typeface; import android.os.Build; import android.support.v4.view.MotionEventCompat; import android.text.ClipboardManager; import android.view.ActionMode; import android.view.InputDevice; import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; import android.view.MotionEvent; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.widget.TextView; import de.mud.terminal.VDUBuffer; import de.mud.terminal.vt320; /** * Custom TextView {@link TextView} which is intended to (invisibly) be on top of the TerminalView * (@link TerminalView) in order to allow the user to select and copy the text of the bitmap below. * * @author rhansby */ @TargetApi(11) public class TerminalTextViewOverlay extends TextView { public TerminalView terminalView; // ryan: this name sucks private String currentSelection = ""; private ActionMode selectionActionMode; private ClipboardManager clipboard; private int oldBufferHeight = 0; private int oldScrollY = -1; public TerminalTextViewOverlay(Context context, TerminalView terminalView) { super(context); this.terminalView = terminalView; clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); setTextColor(Color.TRANSPARENT); setTypeface(Typeface.MONOSPACE); setTextIsSelectable(true); setCustomSelectionActionModeCallback(new TextSelectionActionModeCallback()); } public void refreshTextFromBuffer() { VDUBuffer vb = terminalView.bridge.getVDUBuffer(); int numRows = vb.getBufferSize(); int numCols = vb.getColumns() - 1; oldBufferHeight = numRows; StringBuilder buffer = new StringBuilder(); int previousTotalLength = 0; for (int r = 0; r < numRows && vb.charArray[r] != null; r++) { for (int c = 0; c < numCols; c++) { buffer.append(vb.charArray[r][c]); } // Truncate all the new whitespace without removing the old data. while (buffer.length() > previousTotalLength && Character.isWhitespace(buffer.charAt(buffer.length() - 1))) { buffer.setLength(buffer.length() - 1); } // Make sure each line ends with a carriage return and then remember the buffer // at that length. buffer.append('\n'); previousTotalLength = buffer.length(); } oldScrollY = vb.getWindowBase() * getLineHeight(); setText(buffer); } /** * If there is a new line in the buffer, add an empty line * in this TextView, so that selection seems to move up with the * rest of the buffer. */ public void onBufferChanged() { VDUBuffer vb = terminalView.bridge.getVDUBuffer(); int numRows = vb.getBufferSize(); int numNewRows = numRows - oldBufferHeight; if (numNewRows <= 0) { return; } StringBuilder newLines = new StringBuilder(numNewRows); for (int i = 0; i < numNewRows; i++) { newLines.append('\n'); } oldScrollY = (vb.getWindowBase() + numNewRows) * getLineHeight(); oldBufferHeight = numRows; append(newLines); } @Override public boolean onPreDraw() { boolean superResult = super.onPreDraw(); if (oldScrollY >= 0) { scrollTo(0, oldScrollY); oldScrollY = -1; } return superResult; } private void closeSelectionActionMode() { if (selectionActionMode != null) { selectionActionMode.finish(); selectionActionMode = null; } } public void copyCurrentSelectionToClipboard() { if (currentSelection.length() != 0) { clipboard.setText(currentSelection); } closeSelectionActionMode(); } private void pasteClipboard() { String clip = ""; if (clipboard.hasText()) { clip = clipboard.getText().toString(); } terminalView.bridge.injectString(clip); } @Override protected void onSelectionChanged(int selStart, int selEnd) { if (selStart >= 0 && selEnd >= 0 && selStart <= selEnd) { currentSelection = getText().toString().substring(selStart, selEnd); } super.onSelectionChanged(selStart, selEnd); } @Override public void scrollTo(int x, int y) { int lineMultiple = y / getLineHeight(); TerminalBridge bridge = terminalView.bridge; bridge.buffer.setWindowBase(lineMultiple); super.scrollTo(0, lineMultiple * getLineHeight()); } @Override public boolean onTouchEvent(MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN) { // Selection may be beginning. Sync the TextView with the buffer. refreshTextFromBuffer(); } // Mouse input is treated differently: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH && MotionEventCompat.getSource(event) == InputDevice.SOURCE_MOUSE) { if (onMouseEvent(event, terminalView.bridge)) { return true; } terminalView.viewPager.setPagingEnabled(true); } else { if (terminalView.onTouchEvent(event)) { return true; } } return super.onTouchEvent(event); } @Override @TargetApi(12) public boolean onGenericMotionEvent(MotionEvent event) { if ((MotionEventCompat.getSource(event) & InputDevice.SOURCE_CLASS_POINTER) != 0) { switch (event.getAction()) { case MotionEvent.ACTION_SCROLL: // Process scroll wheel movement: float yDistance = MotionEventCompat.getAxisValue(event, MotionEvent.AXIS_VSCROLL); vt320 vtBuffer = (vt320) terminalView.bridge.buffer; boolean mouseReport = vtBuffer.isMouseReportEnabled(); if (mouseReport) { int row = (int) Math.floor(event.getY() / terminalView.bridge.charHeight); int col = (int) Math.floor(event.getX() / terminalView.bridge.charWidth); vtBuffer.mouseWheel( yDistance > 0, col, row, (event.getMetaState() & KeyEvent.META_CTRL_ON) != 0, (event.getMetaState() & KeyEvent.META_SHIFT_ON) != 0, (event.getMetaState() & KeyEvent.META_META_ON) != 0); return true; } } } return super.onGenericMotionEvent(event); } /** * @param event * @param bridge * @return True if the event is handled. */ @TargetApi(14) private boolean onMouseEvent(MotionEvent event, TerminalBridge bridge) { int row = (int) Math.floor(event.getY() / bridge.charHeight); int col = (int) Math.floor(event.getX() / bridge.charWidth); int meta = event.getMetaState(); boolean shiftOn = (meta & KeyEvent.META_SHIFT_ON) != 0; vt320 vtBuffer = (vt320) bridge.buffer; boolean mouseReport = vtBuffer.isMouseReportEnabled(); // MouseReport can be "defeated" using the shift key. if (!mouseReport || shiftOn) { if (event.getAction() == MotionEvent.ACTION_DOWN) { if (event.getButtonState() == MotionEvent.BUTTON_TERTIARY) { // Middle click pastes. pasteClipboard(); return true; } // Begin "selection mode" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { closeSelectionActionMode(); } } else if (event.getAction() == MotionEvent.ACTION_MOVE) { // In the middle of selection. if (selectionActionMode == null) { selectionActionMode = startActionMode(new TextSelectionActionModeCallback()); } int selectionStart = getSelectionStart(); int selectionEnd = getSelectionEnd(); if (selectionStart > selectionEnd) { int tempStart = selectionStart; selectionStart = selectionEnd; selectionEnd = tempStart; } currentSelection = getText().toString().substring(selectionStart, selectionEnd); } } else if (event.getAction() == MotionEvent.ACTION_DOWN) { terminalView.viewPager.setPagingEnabled(false); vtBuffer.mousePressed( col, row, mouseEventToJavaModifiers(event)); return true; } else if (event.getAction() == MotionEvent.ACTION_UP) { terminalView.viewPager.setPagingEnabled(true); vtBuffer.mouseReleased(col, row); return true; } else if (event.getAction() == MotionEvent.ACTION_MOVE) { int buttonState = event.getButtonState(); int button = (buttonState & MotionEvent.BUTTON_PRIMARY) != 0 ? 0 : (buttonState & MotionEvent.BUTTON_SECONDARY) != 0 ? 1 : (buttonState & MotionEvent.BUTTON_TERTIARY) != 0 ? 2 : 3; vtBuffer.mouseMoved( button, col, row, (meta & KeyEvent.META_CTRL_ON) != 0, (meta & KeyEvent.META_SHIFT_ON) != 0, (meta & KeyEvent.META_META_ON) != 0); return true; } return false; } /** * Takes an android mouse event and produces a Java InputEvent modifiers int which can be * passed to vt320. * @param mouseEvent The {@link MotionEvent} which should be a mouse click or release. * @return A Java InputEvent modifier int. See * http://docs.oracle.com/javase/7/docs/api/java/awt/event/InputEvent.html */ @TargetApi(14) private static int mouseEventToJavaModifiers(MotionEvent mouseEvent) { if (MotionEventCompat.getSource(mouseEvent) != InputDevice.SOURCE_MOUSE) return 0; int mods = 0; // See http://docs.oracle.com/javase/7/docs/api/constant-values.html int buttonState = mouseEvent.getButtonState(); if ((buttonState & MotionEvent.BUTTON_PRIMARY) != 0) mods |= 16; if ((buttonState & MotionEvent.BUTTON_SECONDARY) != 0) mods |= 8; if ((buttonState & MotionEvent.BUTTON_TERTIARY) != 0) mods |= 4; // Note: Meta and Ctrl are intentionally swapped here to keep logic in vt320 simple. int meta = mouseEvent.getMetaState(); if ((meta & KeyEvent.META_META_ON) != 0) mods |= 2; if ((meta & KeyEvent.META_SHIFT_ON) != 0) mods |= 1; if ((meta & KeyEvent.META_CTRL_ON) != 0) mods |= 4; return mods; } @Override public boolean onCheckIsTextEditor() { // This prevents a cursor being displayed within the text. return false; } @Override public InputConnection onCreateInputConnection(EditorInfo outAttrs) { return terminalView.onCreateInputConnection(outAttrs); } private class TextSelectionActionModeCallback implements ActionMode.Callback { private static final int COPY = 0; private static final int PASTE = 1; @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { return false; } @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { TerminalTextViewOverlay.this.selectionActionMode = mode; menu.clear(); menu.add(0, COPY, 0, R.string.console_menu_copy) .setIcon(R.drawable.ic_action_copy) .setShowAsAction(MenuItem.SHOW_AS_ACTION_WITH_TEXT | MenuItem.SHOW_AS_ACTION_IF_ROOM); menu.add(0, PASTE, 1, R.string.console_menu_paste) .setIcon(R.drawable.ic_action_paste) .setShowAsAction(MenuItem.SHOW_AS_ACTION_WITH_TEXT | MenuItem.SHOW_AS_ACTION_IF_ROOM); return true; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { switch (item.getItemId()) { case COPY: copyCurrentSelectionToClipboard(); return true; case PASTE: pasteClipboard(); mode.finish(); return true; } return false; } @Override public void onDestroyActionMode(ActionMode mode) { } } }